/*
 * Copyright (c) 2011-2012 Alexander Dubu
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * o  Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 * o  Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 * o  Neither the name Axil nor the names of its contributors may be used to
 *    endorse or promote products derived from this software without specific
 *    prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 */
package axil.stdlib.time.type;

import axil.api.AxilObject;
import axil.api.AxilType;
import axil.api.Formatting;
import axil.api.Persona;
import axil.api.error.AxilException;
import axil.stdlib.core.type.Atom;
import axil.stdlib.core.type.Nil;
import axil.stdlib.numbers.type.BigInt;
import axil.stdlib.numbers.type.Int;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.TimeZone;

import static axil.framework.Functions.*;


/**
 * An object representing an exact point in time, to millisecond granularity,
 * centered on UTC.
 */
public class Timestamp extends Atom implements Temporal {
    // Constants for TICKS conversion.
    private static final long TICKS_PER_SECOND = 1000;
    private static final long TICKS_PER_MINUTE = 60 * TICKS_PER_SECOND;
    private static final long TICKS_PER_HOUR = 60 * TICKS_PER_MINUTE;
    private static final long TICKS_PER_DAY = 24 * TICKS_PER_HOUR;
    private static final long TICKS_PER_WEEK = 7 * TICKS_PER_DAY;

    // Output Timestamps in ISO timestamp format; no seconds (since Axil doesn't support them).
    private static final DateFormat ISO_DATETIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'");
    static {
        ISO_DATETIME_FORMAT.setTimeZone(TimeZone.getTimeZone("GMT"));
    }


    public static AxilType type = new Timestamp_Type();

	private long ticks;


	public Timestamp(long ticks) {
		this.ticks = ticks;
	}


	public static Timestamp now() {
		return new Timestamp(System.currentTimeMillis());
	}


	/**
	 * Get the type metadata for this object. The type metadata contains
	 * information about the type itself, as an object.
	 */
	public AxilType type() {
		return type;
	}


	/**
	 * Return the host application (or built-in Java) object that most closely
	 * matches this value object. Return returned value is never null unless
	 * this value object represents the special 'nil' object.
	 */
	public Object intrinsic() {
		return new java.sql.Timestamp(ticks());
	}


	/**
	 * Coerce this object into an instance of the given type. If this object
	 * cannot be meaningfully represented as an instance of that type, then an
	 * exception is thrown.
	 *
	 * @param type
	 * 	The type to be coerced to. The object given cannot be null.
	 *
	 * @return
	 * 	Returns an instance of the given type. If this object is nil, then nil
	 * 	is returned.
	 */
	public AxilObject coerce(AxilType type) throws AxilException {
		if (type == type()) {
			return this;
		}
		if (type == BigInt.type) {
			return BigInt.from(ticks);
		}
		throw error(axil(), "cannot-coerce-object",
		            nv("from", type()), nv("to", type));
	}


	public static Timestamp from(java.sql.Timestamp object) {
		return new Timestamp(object.getTime());
	}


	public Date date(TimeZone tz) {
		Calendar cal = Calendar.getInstance(tz);
		cal.setTimeInMillis(ticks);
		return Date.from(cal);
	}


	public Time time(TimeZone tz) {
		Calendar cal = Calendar.getInstance(tz);
		cal.setTimeInMillis(ticks);
		int hours = cal.get(Calendar.HOUR_OF_DAY);
		int minutes = cal.get(Calendar.MINUTE);
		return new Time(hours, minutes);
	}


	public long ticks() {
		return ticks;
	}


	/**
	 * Extract from the temporal value the indicated unit. If the unit makes no
	 * sense for the type, then an exception is thrown.
	 */
	public int extract(TimeZone tz, Part part) {
		Calendar cal = Calendar.getInstance();
		cal.setTimeZone(tz);
		cal.setTimeInMillis(ticks());
		switch (part) {
		case millisecond:
			return cal.get(Calendar.MILLISECOND);
		case second:
			return cal.get(Calendar.SECOND);
		case minute:
			return cal.get(Calendar.MINUTE);
		case hour:
			return cal.get(Calendar.HOUR_OF_DAY);
		case day:
			return cal.get(Calendar.DAY_OF_MONTH);
		case weekday:
			return cal.get(Calendar.DAY_OF_WEEK);
		case month:
			return cal.get(Calendar.MONTH) + 1;
		case quarter:
			return Date.quarter(extract(tz, Part.month));
		case year:
			return cal.get(Calendar.YEAR);

		default:
			throw error(axil(), "invalid-temporal-part",
			            nv("type", this.type()), nv("part", part.name()));
		}
	}


	/**
	 * Returns a hash code value for the object. All Axil values must provide a
	 * meaningful implementation of this function to enable themselves to be
	 * placed in a map or set.
	 */
	protected int hash() {
		return (int)ticks;
	}


	/**
	 * Tell if this object is equivalent to the given object. The object given
	 * is never null. The object given may be of any type of value object.  If
	 * the given object is not a suitable type for comparison, a
	 * ClassCastException may be thrown.
	 */
	public boolean equalTo(AxilObject object) {
        if (object instanceof Timestamp) {
            return ticks == ((Timestamp)object).ticks;
        }
        if (object instanceof Time) {
            // compare time component
            Time timePart = this.time(persona().timezone());
            return timePart.equalTo(object);
        }
        if (object instanceof Date) {
            // compare date component
            Date datePart = this.date(persona().timezone());
            return datePart.equalTo(object);
        }
        return false;
	}


	/**
	 * Compares this object with the specified object for order. If the given
	 * object is not a suitable type for comparison, a ClassCastException may
	 * be thrown.
	 *
	 * @param object
	 * 	The object to compare against. The given object cannot be null but may
	 * 	be any Axil object.
	 *
	 * @return
	 * 	Returns a negative integer, zero, or a positive integer as this object
	 * 	is less than, equal to, or greater than the specified object.
	 */
	public int comparedTo(AxilObject object) {
		if (object instanceof Timestamp) {
			return (int)(ticks - ((Timestamp)object).ticks);
		}
        if (object instanceof Time) {
            // compare time component
            Time timePart = this.time(persona().timezone());
            return timePart.comparedTo(object);
        }
        if (object instanceof Date) {
            // compare date component
            Date datePart = this.date(persona().timezone());
            return datePart.comparedTo(object);
        }
        // "nil" comparison; always return 1 (nil less than everything).
        if (object == Nil.object) {
            return 1;
        }
        // Incorrect comparison.
        throw error(axil(), "order-comparison",
                nv("this", this.type()),
                nv("that", object.type()));
    }


	/**
	 * Add the given value to this value, returning a new object. The given
	 * value is never null or nil. The value may be a numeric type of any other
	 * type of object that can sensibly implement the Added interface.
	 */
	public AxilObject add(AxilObject object) {
        if (object == Nil.object) {
            return Nil.object;
        }
        if (object instanceof Interval) {
            return future((Interval)object);
        }
        throw error(axil(), "expecting-timestamp-add", nv("actual", object.type()));
    }


    private Timestamp future(Interval interval) {
        return ticksAdjust(interval, +1);
    }


    /**
	 * Subtract the given object from this object. The given value is never
	 * null or nil. The value may be a numeric type of any other type of object
	 * that can sensibly implement the Subtracted interface.
	 */
	public AxilObject subtract(AxilObject object) {
        if (object == Nil.object) {
            return Nil.object;
        }
        if (object instanceof Interval) {
            return past((Interval)object);
        }
        throw error(axil(), "expecting-timestamp-subtract", nv("actual", object.type()));
    }


    private Timestamp past(Interval interval) {
        return ticksAdjust(interval, -1);
    }

    /**
     * Returns a new Timestamp with the ticks adjusted the interval amount in the direction of the sign +1/-1.
     */
    private Timestamp ticksAdjust(Interval interval, int sign) {
        switch(interval.span()) {
            // Date related
            case day:
                return new Timestamp(ticks() + sign * (interval.days() * TICKS_PER_DAY));

            case week:
                // Can't use the interval.days(); the Interval conversion has precision issues.
                int weeks = (int)interval.magnitude().whole();
                return new Timestamp(ticks() + sign * (weeks * TICKS_PER_WEEK));

            case month: {
                int months = (int)interval.magnitude().whole();
                Calendar cal = Calendar.getInstance();
                cal.setTimeInMillis(ticks());
                cal.add(Calendar.MONTH, sign * months);
                // adjust for fraction days of the month
                int days = 0;
                if (interval.magnitude().fractional()) {
                    days = (int)interval.magnitude().tenths() * Date.DAYS_PER_MONTH;
                    cal.add(Calendar.DAY_OF_WEEK_IN_MONTH, sign * days);
                }
                return new Timestamp(cal.getTimeInMillis());
            }

            case quarter:
                // Interval conversion has precision issues that conflict with this more general need.
                int quarters = 3 * (int)interval.magnitude().whole();
                return ticksAdjust(new Interval(new Int(quarters), Interval.Span.month), sign);

            case year: {
                int years = (int)interval.magnitude().whole();
                Calendar cal = Calendar.getInstance();
                cal.setTimeInMillis(ticks());
                cal.add(Calendar.YEAR, sign * years);
                // adjust for fraction months of the year
                int months = 0;
                if (interval.magnitude().fractional()) {
                    months = (int)interval.magnitude().tenths() * 12;
                    cal.add(Calendar.MONTH, sign * months);
                }
                return new Timestamp(cal.getTimeInMillis());
            }

            // Time related
            case hour:
                int hours = (int)interval.magnitude().whole();
                int minutes = (int)(60 * interval.magnitude().tenths());
                return new Timestamp(ticks() + sign * ((hours * TICKS_PER_HOUR) + (minutes * TICKS_PER_MINUTE)));

            case minute:
                int mins = (int)interval.magnitude().whole();
                return new Timestamp(ticks() + sign * (mins * TICKS_PER_MINUTE));

            default:
                /*
                 * In this case, the interval is either too large or too
                 * small to have a material impact on the time. So, we
                 * simply return this object.
                 */
                return this;
        }
    }


    /**
	 * Format this object in a locale-friendly manner. The string returned is
	 * a localized representation of this object using the locale and timezone
	 * associated with this persona. The formatting given is never null and
	 * matches formatting suitable for this type.
	 */
	public String format(Persona persona, Formatting formatting) {
		return formatting.format(this, persona);
	}


	/**
	 * Return a string representation of this object. The string returned is
	 * never null. The string is formatted in ISO format. http://en.wikipedia.org/wiki/ISO_8601
	 */
	public String toString() {
		return ISO_DATETIME_FORMAT.format(new java.sql.Timestamp(ticks));
	}
}
